跳到主要内容

一等函数

不管别人怎么说或怎么想,我从未觉得 Python 受到来自函数式语言的太多影响。我非常熟悉命令式语言,如 C 和 Algol 68,虽然我把函数定为一等对象,但是我并不把 Python 当作函数式编程语言。

——Guido van Rossum,Python 仁慈的独裁者

把函数作为对象

在 Python 中,函数是一等对象。编程语言理论家把“一等对象”定义为满足下述条件的程序实体:

  • 在运行时创建
  • 能赋值给变量或数据结构中的元素
  • 能作为参数传给函数
  • 能作为函数的返回结果

在 Python 中,整数、字符串和字典都是一等对象——没什么特别的。

>>> def factorial(n):  
... '''returns n!'''
... return 1 if n < 2 else n * factorial(n-1)
...
>>> factorial(42)
1405006117752879898543142606244511569936384000000000
>>> factorial.__doc__ # 这个 factorial 是 function 类的实例
'returns n!'
>>> type(factorial)
<class 'function'>

高阶函数

接受函数为参数,或者把函数作为结果返回的函数是高阶函数(higher-order function)

在函数式编程范式中,最为人熟知的高阶函数有 mapfilterreduce

mapfilterreduce 的现代替代品

列表推导或生成器表达式具有 mapfilter 两个函数的功能,而且更易于阅读

>>> list(map(fact, range(6)))
[1, 1, 2, 6, 24, 120]
>>> [fact(n) for n in range(6)]
[1, 1, 2, 6, 24, 120]
>>> list(map(factorial, filter(lambda n: n % 2, range(6))))
[1, 6, 120]
>>> [factorial(n) for n in range(6) if n % 2]
[1, 6, 120]

匿名函数

为了使用高阶函数,有时创建一次性的小型函数会更便利,这便是匿名函数存在的原因。

lambda 关键字在 Python 表达式内创建匿名函数。

然而,Python 简单的句法限制了 lambda 函数的定义体只能使用纯表达式。换句话说,lambda 函数的定义体中不能赋值,也不能使用 whiletry 等 Python 语句。

>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'raspberry', 'banana']
>>> sorted(fruits, key=lambda word: word[::-1])
['banana', 'apple', 'fig', 'raspberry', 'strawberry', 'cherry']

除了作为参数传给高阶函数之外,Python 很少使用匿名函数。由于句法上的限制,非平凡的 lambda 表达式要么难以阅读,要么无法写出。

可调用对象

除了用户定义的函数,调用运算符(即 ())还可以应用到其他对象上。如果想判断对象能否调用,可以使用内置的 callable() 函数。

>>> abs, str, 13
(<built-in function abs>, <class 'str'>, 13)
>>> [callable(obj) for obj in (abs, str, 13)]
[True, True, False]

Python 数据模型文档列出了 7 种可调用对象。

  1. 自定义的函数
  2. 内置函数
    • 使用 C 语言(CPython)实现的函数,如 lentime.strftime
  3. 内置方法
    • 使用 C 语言实现的方法,如 dict.get
  4. 方法
  5. 类(创建实例时)
    • 调用类时会运行类的 __new__ 方法创建一个实例,然后运行 __init__ 方法,初始化实例,最后把实例返回给调用方。因为 Python 没有 new 运算符,所以调用类相当于调用函数。
  6. 类的实例(要求实现 __call__ 方法)
  7. 生成器函数(yield)

__call__

下面的示例实现了 BingoCage 类。这个类的实例使用任何可迭代对象构建,而且会在内部存储一个随机顺序排列的列表。调用实例会取出一个元素。

import random

class BingoCage:

def __init__(self, items):
self._items = list(items)
random.shuffle(self._items)

def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage')

def __call__(self): # bingo.pick() 的快捷方式是 bingo()
return self.pick()
>>> bingo = BingoCage(range(3))
>>> bingo.pick()
1
>>> bingo()
0
>>> callable(bingo)
True

实现 __call__ 方法的类是创建函数类对象的简便方式,此时必须在内部维护一个状态,让它在调用之间可用,例如 BingoCage 中的剩余元素。装饰器就是这样。装饰器必须是函数,而且有时要在多次调用之间“记住”某些事 (例如备忘(memoization),即缓存消耗大的计算结果,供后面使用)。

另一种对 __call__ 的解释

该方法的功能类似于在类中重载 () 运算符,使得类实例对象可以像调用普通函数那样,以 对象名() 的形式使用。

函数内省

同样的,Python 的函数也可以被视为对象。 函数有很多属性,这一特点使得函数表现得像一个对象。通过函数内省 dir() 能够得到这些属性的列表。

>>> dir(factorial)
['__annotations__', '__call__', '__class__', '__closure__', '__code__',
'__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__get__', '__getattribute__', '__globals__',
'__gt__', '__hash__', '__init__', '__kwdefaults__', '__le__', '__lt__',
'__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__',
'__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
'__subclasshook__']

其中大多数属性是 Python 对象共有的

下面的示例列出了常规对象没有而函数有的属性

>>> class C: pass  # ➊
>>> obj = C() # ➋
>>> def func(): pass # ➌
>>> sorted(set(dir(func)) - set(dir(obj))) # ➍
['__annotations__', '__call__', '__closure__', '__code__', '__defaults__',
'__get__', '__globals__', '__kwdefaults__', '__name__', '__qualname__']
名称类型说明
__annotations__dict参数和返回值的注解
__call__method-wrapper实现 () 运算符;即可调用对象协议
__closure__tuple函数闭包,即自由变量的绑定(通常是 None
__code__code编译成字节码的函数元数据和函数定义体
__defaults__tuple形式参数的默认值
__get__method-wrapper实现只读描述符协议(参见第 20 章)
__globals__dict函数所在模块中的全局变量
__kwdefaults__dict仅限关键字形式参数的默认值
__name__str函数名称
__qualname__str函数的限定名称,如 Random.choice

定位参数与仅限关键字参数

两类实参:

  • 位置参数 (positional):传参时前面不带 " 变量名 =",顺序不可变,按顺序赋给相应的局部变量.
  • 关键字参数 (keyword):传参时前面加上 " 变量名=",顺序可变,按名称赋给同名的局部变量.

Python 最好的特性之一是提供了极为灵活的参数处理机制

仅限关键字参数 (keyword-only) 是 Python 3 新增的特性,它是在 * 后面定义的参数(或在 *args 后面定义),传参时必需带变量名

下面示例中的 tag 函数用于生成 HTML 标签;使用名为 cls 的关键字参数传入 “class” 属性,这是一种变通方法,因为 “class” 是 Python 的关键字

>>> tag('br')  
'<br />'
>>> tag('p', 'hello')
'<p>hello</p>'
>>> print(tag('p', 'hello', 'world'))
<p>hello</p>
<p>world</p>
>>> tag('p', 'hello', id=33)
'<p id="33">hello</p>'
>>> print(tag('p', 'hello', 'world', cls='sidebar'))
<p class="sidebar">hello</p>
<p class="sidebar">world</p>
>>> tag(content='testing', name="img")
'<img content="testing" />'
>>> my_tag = {'name': 'img', 'title': 'Sunset Boulevard',
... 'src': 'sunset.jpg', 'cls': 'framed'}
>>> tag(**my_tag)
'<img class="framed" src="sunset.jpg" title="Sunset Boulevard" />'

在上面的示例中,cls 参数只能通过关键字参数指定,它一定不会捕获未命名的定位参数。

定义函数时若想指定仅限关键字参数,要把它们放到前面有 * 的参数后面。如果不想支持数量不定的定位参数,但是想支持仅限关键字参数,在签名中放一个 *,如下所示:

>>> def f(a, *, b):
... return a, b
...
>>> f(1, b=2)
(1, 2)

获取关于参数的信息

使用内置方法

Python 的函数有大量可用于获取函数参数信息的内置方法:

  • .__defaults__: 获取参数的默认值
  • .__code__.co_varnames: 参数名称,包括函数中创建的临时变量
  • .__code__.co_argcount: 参数总数
def clip(text, max_len=80):
"""在max_len前面或后面的第一个空格处截断文本"""
end = None
if len(text) > max_len:
space_before = text.rfind(' ', 0, max_len)
if space_before >= 0:
end = space_before
else:
space_after = text.rfind(' ', max_len)
if space_after >= 0:
end = space_after
if end is None: # 没找到空格
end = len(text)
return text[:end].rstrip()
>>> from clip import clip
>>> clip.__defaults__
(80,)
>>> clip.__code__ # doctest: +ELLIPSIS
<code object clip at 0x...>
>>> clip.__code__.co_varnames
('text', 'max_len', 'end', 'space_before', 'space_after')
>>> clip.__code__.co_argcount
2

上述方法能够获取函数参数信息,但是不易处理和后续分析

  • 参数名称在 __code__.co_varnames 中,不过里面还有函数定义体中创建的局部变量。
  • 因此,参数名称是前 N 个字符串,N 的值由 __code__.co_argcount 确定。
    • 顺便说一下,这里不包含前缀为 *** 的变长参数。
  • 参数的默认值只能通过它们在 __defaults__ 元组中的位置确定,因此要从后向前扫描才能把参数和默认值对应起来。
    • 在这个示例中 clip 函数有两个参数,textmax_len,其中一个有默认值,即 80,因此它必然属于最后一个参数,即 max_len。这有违常理。

inspect 模块则提供了快速分析函数参数的功能

使用 inspect

inspect.signature 函数返回一个 inspect.Signature 对象,它有一个 parameters 属性,这是一个有序映射,把参数名和 inspect.Parameter 对象对应起来。各个 Parameter 属性也有自己的属性,例如 namedefaultkind。特殊的 inspect._empty 值表示没有默认值,考虑到 None 是有效的默认值(也经常这么做),而且这么做是合理的:

>>> from clip import clip
>>> from inspect import signature
>>> sig = signature(clip)
>>> sig # doctest: +ELLIPSIS
<inspect.Signature object at 0x...>
>>> str(sig)
'(text, max_len=80)'
>>> for name, param in sig.parameters.items():
... print(param.kind, ':', name, '=', param.default)
...
POSITIONAL_OR_KEYWORD : text = <class 'inspect._empty'>
POSITIONAL_OR_KEYWORD : max_len = 80

kind 属性的值是 _ParameterKind 类中的 5 个值之一,列举如下。

  • POSITIONAL_OR_KEYWORD
    • 可以通过定位参数和关键字参数传入的形参(多数 Python 函数的参数属于此类)。
  • VAR_POSITIONAL
    • 定位参数元组。
  • VAR_KEYWORD
    • 关键字参数字典。
  • KEYWORD_ONLY
    • 仅限关键字参数(Python 3 新增)。
  • POSITIONAL_ONLY
    • 仅限定位参数;目前,Python 声明函数的句法不支持,但是有些使用 C 语言实现且不接受关键字参数的函数(如 divmod)支持。

inspect.Signature 对象有个 bind 方法,它可以把任意个参数绑定到签名中的形参上,所用的规则与实参到形参的匹配方式一样。框架可以使用这个方法在真正调用函数前验证参数

>>> import inspect
>>> sig = inspect.signature(tag)
>>> my_tag = {'name': 'img', 'title': 'Sunset Boulevard',
... 'src': 'sunset.jpg', 'cls': 'framed'}
>>> bound_args = sig.bind(**my_tag)
>>> bound_args
<inspect.BoundArguments object at 0x...>
>>> for name, value in bound_args.arguments.items():
... print(name, '=', value)
...
name = img
cls = framed
attrs = {'title': 'Sunset Boulevard', 'src': 'sunset.jpg'}
>>> del my_tag['name']
>>> bound_args = sig.bind(**my_tag)
Traceback (most recent call last):
...
TypeError: 'name' parameter lacking default value

函数注解

Python 允许对函数的形参以及函数本身进行注释。例如标注某一参数的类型为 "int" 或者 "str"。

def func(text:str, max_len:'int > 0'=80) -> str:
pass

注解中最常用的类型是类(如 strint)和字符串(如 'int > 0')。在上面的示例中,max_len 参数的注解用的是字符串。

Python 不会对这些注释进行任何处理,不会根据注释对参数进行检查、强制处理或者验证,仅是存储到 __annotations__ 属性中,以供 IDE(例如为 IDE 的静态类型检查功能提供信息)、框架和装饰器使用。

>>> from clip_annot import clip
>>> clip.__annotations__
{'text': <class 'str'>, 'max_len': 'int > 0', 'return': <class 'str'>}

支持函数式编程的包

虽然 Guido 明确表明,Python 的目标不是变成函数式编程语言,但是得益于 operatorfunctools 等包的支持,函数式编程风格也可以信手拈来

operator 模块

在函数式编程中,经常需要把算术运算符当作函数使用。例如,不使用递归计算阶乘。求和可以使用 sum 函数,但是求积则没有这样的函数。我们可以使用 reduce 函数,但是需要一个函数计算序列中两个元素之积:

# 使用 reduce 函数和一个匿名函数计算阶乘
from functools import reduce
def fact(n):
return reduce(lambda a, b: a*b, range(1, n+1))

operator 模块为多个算术运算符提供了对应的函数,从而避免编写 lambda a, b: a*b 这种平凡的匿名函数。

from functools import reduce
from operator import mul

def fact(n):
return reduce(mul, range(1, n+1))

operator 模块中还有一类函数,能替代从序列中取出元素或读取对象属性的 lambda 表达式:因此,itemgetterattrgetter 其实会自行构建函数。

itemgetter

itemgetter 的常见用途:根据元组的某个字段给元组列表排序。

下面的示例使用了 itemgetter 排序一个元组列表

>>> metro_data = [
... ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
... ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
... ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
... ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
... ('Sao Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
... ]
>>>
>>> from operator import itemgetter
>>> for city in sorted(metro_data, key=itemgetter(1)):
... print(city)
...
('Sao Paulo', 'BR', 19.649, (-23.547778, -46.635833))
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889))
('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
('Mexico City', 'MX', 20.142, (19.433333, -99.133333))
('New York-Newark', 'US', 20.104, (40.808611, -74.020386))

如果把多个参数传给 itemgetter,它构建的函数会返回提取的值构成的元组:

>>> cc_name = itemgetter(1, 0)
>>> for city in metro_data:
... print(cc_name(city))
...
('JP', 'Tokyo')
('IN', 'Delhi NCR')
('MX', 'Mexico City')
('US', 'New York-Newark')
('BR', 'Sao Paulo')

itemgetter 使用 [] 运算符,因此它不仅支持序列,还支持映射和任何实现 __getitem__ 方法的类

attrgetter

attrgetteritemgetter 作用类似,它创建的函数根据名称提取对象的属性。如果把多个属性名传给 attrgetter,它也会返回提取的值构成的元组。此外,如果参数名中包含 .(点号),attrgetter 会深入嵌套对象,获取指定的属性。

# 定义一个 namedtuple,名为 metro_data,演示使用 attrgetter 处理它
>>> from collections import namedtuple
>>> LatLong = namedtuple('LatLong', 'lat long') # ➊
>>> Metropolis = namedtuple('Metropolis', 'name cc pop coord') # ➋
>>> metro_areas = [Metropolis(name, cc, pop, LatLong(lat, long)) # ➌
... for name, cc, pop, (lat, long) in metro_data]
>>> metro_areas[0]
Metropolis(name='Tokyo', cc='JP', pop=36.933, coord=LatLong(lat=35.689722,
long=139.691667))
>>> metro_areas[0].coord.lat # ➍
35.689722
>>> from operator import attrgetter
>>> name_lat = attrgetter('name', 'coord.lat') # ➎
>>>
>>> for city in sorted(metro_areas, key=attrgetter('coord.lat')): # ➏
... print(name_lat(city)) # ➐
...
('Sao Paulo', -23.547778)
('Mexico City', 19.433333)
('Delhi NCR', 28.613889)
('Tokyo', 35.689722)
('New York-Newark', 40.808611)

methodcaller

methodcaller 的作用与 attrgetteritemgetter 类似,它会自行创建函数

methodcaller 创建的函数会在对象上调用参数指定的方法

>>> from operator import methodcaller
>>> s = 'The time has come'
>>> upcase = methodcaller('upper')
>>> upcase(s)
'THE TIME HAS COME'
>>> hiphenate = methodcaller('replace', ' ', '-') # 绑定额外参数
>>> hiphenate(s)
'The-time-has-come'

其他

>>> [name for name in dir(operator) if not name.startswith('_')]
['abs', 'add', 'and_', 'attrgetter', 'concat', 'contains',
'countOf', 'delitem', 'eq', 'floordiv', 'ge', 'getitem', 'gt',
'iadd', 'iand', 'iconcat', 'ifloordiv', 'ilshift', 'imod', 'imul',
'index', 'indexOf', 'inv', 'invert', 'ior', 'ipow', 'irshift',
'is_', 'is_not', 'isub', 'itemgetter', 'itruediv', 'ixor', 'le',
'length_hint', 'lshift', 'lt', 'methodcaller', 'mod', 'mul', 'ne',
'neg', 'not_', 'or_', 'pos', 'pow', 'rshift', 'setitem', 'sub',
'truediv', 'truth', 'xor']

这 52 个名称中大部分的作用不言而喻。以 i 开头、后面是另一个运算符的那些名称(如 iaddiand 等),对应的是增量赋值运算符(如 +=&= 等)。如果第一个参数是可变的,那么这些运算符函数会就地修改它;否则,作用与不带 i 的函数一样,直接返回运算结果。

使用 functools.partial 冻结参数

functools 模块提供了一系列高阶函数,其中最为人熟知的或许是 reduce。余下的函数中,最有用的是 partial 及其变体,partialmethod

functools.partial 这个高阶函数用于部分应用一个函数。部分应用是指,基于一个函数创建一个新的可调用对象,把原函数的某些参数固定。使用这个函数可以把接受一个或多个参数的函数改编成需要回调的 API,这样参数更少。

>>> from operator import mul
>>> from functools import partial
>>> triple = partial(mul, 3)
>>> triple(7)
21
>>> list(map(triple, range(1, 10)))
[3, 6, 9, 12, 15, 18, 21, 24, 27]